Add custom ObjectPoolProvider support for pooled registrations#15
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #15 +/- ##
============================================
+ Coverage 86.18% 99.62% +13.43%
============================================
Files 9 9
Lines 333 264 -69
Branches 32 36 +4
============================================
- Hits 287 263 -24
+ Misses 26 0 -26
+ Partials 20 1 -19 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@zms9110750 — this is the implementation of the custom pool provider support we designed together in #6. Since you were the original requester and had a clear view of what the usage should feel like, I'd really value your take on whether the result matches what you had in mind. No pressure on timing. First, an apology: this PR got noisy. It grew beyond the core feature to include some build/metadata standardization, dependency bumps, and test cleanup, so the diff is larger and more distracting than the actual change warrants. To save you wading through it, here are the two places that actually show how the feature is used:
Those two together are the whole story; everything else in the diff is plumbing. I also want to be upfront about what we intentionally left out, since it's exactly the territory you were careful about:
None of these are closed doors — if real demand shows up, any of them can be added later without breaking existing registrations. The bias here was just to keep the first cut small and idiomatic rather than pre-build for every edge case. Does the shape — and especially the README usage and the caching example — match what you were hoping for? |
|
I've tested the API with my actual
The shape matches what I was hoping for. Thanks for the implementation. While I don't want to contribute my implementation as part of the package itself, I'd still like to see it appear in the documentation or as an example./// <summary>
/// An <see cref="ObjectPool{T}"/> backed by a shared <see cref="IMemoryCache"/>,
/// capable of holding multiple instances of the same type simultaneously.
/// </summary>
/// <typeparam name="T">The type of objects being pooled.</typeparam>
/// <remarks>
/// <para>
/// Instances are stored under composite keys of <c>(_poolId, index)</c> where
/// <c>_poolId</c> is a unique identifier per pool instance and <c>index</c> is a
/// monotonically increasing stack pointer. This avoids key collisions when
/// multiple pools share the same <see cref="IMemoryCache"/>.
/// </para>
/// <para>
/// <see cref="Get"/> walks the stack from the top down using
/// <see cref="Interlocked.Decrement"/> and returns the first entry still present
/// in the cache (last-in-first-out). Because cache eviction always discards
/// older entries first, any gap near the top implies all entries below have also
/// been evicted, so the walk terminates early. On a complete miss the pool
/// falls back to <see cref="IPooledObjectPolicy{T}.Create"/>.
/// </para>
/// <para>
/// <see cref="Return"/> evaluates <see cref="IPooledObjectPolicy{T}.Return"/>
/// and <see cref="IResettable.TryReset"/> before storing; if either rejects the
/// instance it is disposed immediately. Accepted instances are pushed onto the
/// stack via <see cref="Interlocked.Increment"/> and stored in the cache with a
/// post-eviction callback that disposes the instance when the cache drops it.
/// </para>
/// <para>
/// This pool does not own the <see cref="IMemoryCache"/> — the cache is
/// provided by and disposed by the container. The pool only owns the pooled
/// instances themselves.
/// </para>
/// </remarks>
public sealed class CacheObjectPool<T> : ObjectPool<T>, IDisposable
where T : class
{
private readonly IMemoryCache _cache;
private readonly IPooledObjectPolicy<T> _policy;
private readonly MemoryCacheEntryOptions _options;
private readonly string _poolId;
private int _top;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="CacheObjectPool{T}"/> class.
/// </summary>
/// <param name="cache">
/// The shared memory cache. All pools that share this cache also share its
/// eviction budget.
/// </param>
/// <param name="policy">
/// The policy Autofac supplies. Its <see cref="IPooledObjectPolicy{T}.Create"/>
/// resolves a fully-injected instance through the container; its
/// <see cref="IPooledObjectPolicy{T}.Return"/> fires the pooling callbacks.
/// </param>
/// <param name="options">
/// Optional cache entry options. When <see langword="null"/> a default with
/// a five-minute sliding expiration and a post-eviction disposal callback
/// is used.
/// </param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="cache"/> or <paramref name="policy"/> is
/// <see langword="null"/>.
/// </exception>
public CacheObjectPool(
IMemoryCache cache,
IPooledObjectPolicy<T> policy,
MemoryCacheEntryOptions? options = null)
{
ArgumentNullException.ThrowIfNull(cache);
ArgumentNullException.ThrowIfNull(policy);
_cache = cache;
_policy = policy;
_options = WithEvictionCallback(
options ?? new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(5)
});
_poolId = Guid.NewGuid().ToString("N");
_top = -1;
}
/// <summary>
/// Retrieves an instance from the pool.
/// </summary>
/// <returns>
/// A pooled instance if one is available in the cache, otherwise a new
/// instance created through <see cref="IPooledObjectPolicy{T}.Create"/>
/// (and therefore through the Autofac container).
/// </returns>
/// <remarks>
/// The pool walks its LIFO stack from the top down via
/// <see cref="Interlocked.Decrement"/>, returning the first cache hit.
/// Because <see cref="IMemoryCache"/> evicts older entries first, a miss
/// near the top implies all entries below have also been evicted, so the
/// walk terminates without scanning the full range.
/// </remarks>
public override T Get()
{
ObjectDisposedException.ThrowIf(_disposed, this);
for (var i = Volatile.Read(ref _top); i >= 0; i = Interlocked.Decrement(ref _top))
{
var key = (_poolId, i);
if (_cache.TryGetValue(key, out T? item))
{
_cache.Remove(key);
return item!;
}
}
return _policy.Create();
}
/// <summary>
/// Returns an instance to the pool.
/// </summary>
/// <param name="obj">The instance being returned.</param>
/// <remarks>
/// The instance is stored only when
/// <see cref="IPooledObjectPolicy{T}.Return"/> accepts it and, when the
/// instance implements <see cref="IResettable"/>, its
/// <see cref="IResettable.TryReset"/> succeeds. Rejected instances are
/// disposed immediately. Accepted instances are pushed onto the stack via
/// <see cref="Interlocked.Increment"/> and stored in the cache with a
/// post-eviction callback that disposes the instance when the cache drops
/// it.
/// </remarks>
public override void Return(T obj)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (obj is null)
return;
if (!_policy.Return(obj) ||
(obj is IResettable r && !r.TryReset()))
{
(obj as IDisposable)?.Dispose();
return;
}
var i = Interlocked.Increment(ref _top);
_cache.Set((_poolId, i), obj, _options);
}
/// <summary>
/// Removes all cached instances from the pool without disposing the pool
/// itself.
/// </summary>
/// <remarks>
/// Atomically resets the stack pointer and removes every cache entry in the
/// range that was occupied. Useful for a full flush between game levels or
/// similar reset boundaries.
/// </remarks>
public void Clear()
{
ObjectDisposedException.ThrowIf(_disposed, this);
var last = Interlocked.Exchange(ref _top, -1);
for (var i = 0; i <= last; i++)
_cache.Remove((_poolId, i));
}
/// <summary>
/// Gets the approximate number of instances currently cached in the pool.
/// </summary>
/// <remarks>
/// This is an estimate based on the stack pointer. The actual count may be
/// lower because <see cref="IMemoryCache"/> may have evicted entries
/// without updating the pointer.
/// </remarks>
public int Count
{
get
{
var c = Volatile.Read(ref _top) + 1;
return c >= 0 ? c : 0;
}
}
/// <summary>
/// Disposes the pool, clearing all cached instances.
/// </summary>
/// <remarks>
/// The pool does not own the <see cref="IMemoryCache"/> (it is owned by the
/// container), so only the pooled instances themselves are released. Each
/// evicted entry triggers the post-eviction disposal callback registered in
/// the constructor.
/// </remarks>
public void Dispose()
{
if (_disposed)
return;
Clear();
_disposed = true;
}
private static MemoryCacheEntryOptions WithEvictionCallback(MemoryCacheEntryOptions opts)
{
opts.RegisterPostEvictionCallback(static (_, value, _, _) =>
(value as IDisposable)?.Dispose());
return opts;
}
} |
|
Thank you for putting it through its paces against your real implementation — confirming the per-registration providers, the shared eviction budget across 7 types, and that construction/reuse and the On bundling your That said, I'd genuinely encourage you to share it more widely — a gist, a blog post, or your own small NuGet package would all be great ways to get it in front of people with similar needs, and you'd keep full control over how it evolves. Either way, thank you again — from the original idea through this review, you've made this feature meaningfully better. |
Implements #6 — lets a pooled registration supply a custom
Microsoft.Extensions.ObjectPool.ObjectPoolProviderso the caller controls where pooled instances are stored and when they are evicted, while Autofac keeps owning construction, the pooling callbacks, and disposal of the pool object.Feature
New overloads on
RegistrationExtensions:PooledInstancePerLifetimeScope(Func<IComponentContext, ObjectPoolProvider> providerFactory)PooledInstancePerLifetimeScope(Func<IComponentContext, IPooledRegistrationPolicy<TLimit>> policyFactory, Func<IComponentContext, ObjectPoolProvider> providerFactory)PooledInstancePerMatchingLifetimeScope(Func<IComponentContext, ObjectPoolProvider> providerFactory, params object[] lifetimeScopeTags)PooledInstancePerMatchingLifetimeScope(Func<IComponentContext, IPooledRegistrationPolicy<TLimit>> policyFactory, Func<IComponentContext, ObjectPoolProvider> providerFactory, params object[] lifetimeScopeTags)The provider factory is invoked once when the pool is built, resolved from the pool-owning (root) scope, so the provider and its dependencies can come from the container. Autofac still resolves the pooled instances through the container (DI +
IPooledComponent/IPooledRegistrationPolicycallbacks all work as normal); the provider only controls storage and eviction. With a custom provider,MaximumRetaineddoes not size the pool — the provider owns sizing/eviction — and the documented disposal contract is: the container disposes the pool object if it isIDisposable, and the custom pool is responsible for disposing instances it declines on return or evicts asynchronously.Also included
PooledInstanceContext<T>is nowIDisposableand cascades disposal to the pool, so a retained disposable pool is no longer leaked at container shutdown. AllPoolActivatorpaths now produce the wrapper uniformly, andPoolActivator/RegisterPooledwere unified (one constructor, one normalized branch, one privateRegisterPooled).ObjectPoolProviderthat draws itsIMemoryCachefrom Autofac, undertest/Autofac.Pooling.Test/Examples/Caching/, plus a "custom pool provider" section in the README.PoolService, andPoolActivatortests;InternalsVisibleTofor the strong-named assembly)..snupkgsymbol packages with the packaged README, and test-naming/standardization cleanup.Closes #6.